Skip to main content

02 - 埋点的四种做法

前置01 篇的 span 结构与操作类型。

本篇回答:这些 span 具体由谁产生。四条路线的改动面从"每个调用点加两行"到"一行代码都不动",但改动面越小,能看见的东西也越少 —— 这个取舍怎么算。

本篇会用到的词

意思
instrumentor埋点器。一个负责"把某个库的调用变成 span"的组件,比如专门管 openai 这个包的那个
猴补丁(monkey patch)运行时把一个已经存在的函数替换成自己的版本,原函数在新版本里被调用。埋点库靠它在不改你代码的前提下插入计时和属性记录
entry pointPython 包在安装时向系统声明"我提供了某类插件"的机制。写在 pyproject.toml 里,安装后可被其他程序按组名枚举出来
字节码注入Java 侧的等价手段。JVM 启动时由 agent 拦截类加载,在方法体前后织入计时代码
uprobeLinux 内核提供的用户态函数断点。挂上去之后,目标进程每次调用该函数都会触发一段 eBPF 程序,目标进程本身无感
旁路采集器不在业务进程里,而是从外部观察它 —— 内核探针或网络代理都属于旁路

一、四条路线

按"要改多少东西"排开,四条路线是一条连续的谱系:

改动面越往右越小,能看见的语义也越往右越少 —— 两条曲线方向相反A · 手工埋点每个想看的地方自己开 span改动:每个调用点覆盖:只覆盖你写到的业务语义只有这条能表达「这次是退款流程」B · 库内自动埋点启动处调一次 instrument()改动:启动文件几行覆盖:被支持的库全覆盖框架内部结构看得见链上每��个节点都是 spanC · 进程内零代码改启动命令或加 Pod 注解改动:源码零改覆盖:同 B代码归业务团队、观测归平台团队时的唯一解D · 进程外旁路内核探针或网关代理改动:进程完全不碰覆盖:走网络的那些调用进程内的编排结构它永远看不到侵入性高侵入性低实际生产系统基本都是 B + D 混用:B 拿框架内部结构,D 在网关侧拿一份不可伪造的计费口径。A 只用来补业务语义。
注意 B 和 C 的覆盖率是同一条 —— 它们用的是同一批 instrumentor,区别只在"由谁在什么时候调用它"。真正的能力分界在 C 与 D 之间。

下面逐条拆。

二、做法 A:手工埋点

最直白的一种,OpenTelemetry SDK 原生 API:

from opentelemetry import trace

tracer = trace.get_tracer(__name__)

def summarize_sales(question: str) -> str:
# start_as_current_span 同时做三件事:建 span、把它设成当前上下文、
# 退出 with 块时自动结束。后两件是关键 —— 之后在这个 with 块里
# 产生的任何 span 都会自动成为它的子节点,不需要手动传父节点。
with tracer.start_as_current_span("invoke_agent 销售总结") as span:
span.set_attribute("gen_ai.operation.name", "invoke_agent")
span.set_attribute("gen_ai.agent.name", "sales-summarizer")
# 业务维度:这是自动埋点永远补不上的部分,因为它不在任何库的调用签名里
span.set_attribute("app.workflow", "weekly_report")
resp = client.chat.completions.create(...)
span.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt_tokens)
return resp.choices[0].message.content

A 的唯一不可替代之处,是最后那个 app.workflow 「这次执行属于周报流程」这个事实不在 openai 包的任何一个函数签名里,任何自动埋点都推断不出来。同类的还有租户 ID、实验分组、用户会话 ID。

除此之外它全是缺点:

问题具体表现
漏埋新加的调用点没人记得埋,trace 上出现无法解释的空白
埋错属性名手打,input_tokens 写成 prompt_tokens 不会有任何报错,只是查询时对不上
规范漂移01 篇 5 节那 118 个属性全是 development,改名时要全局搜索替换
框架内部看不见LangGraph 里一次 graph.invoke() 内部可能走了七个节点,手工埋点只能记成一个 span

最后一条是硬伤。用了编排框架之后,你想看的东西大部分发生在框架内部,而框架内部你伸不进手。

2.1 装饰器是 A 的省事版本,不是另一类

各平台都提供了装饰器语法:

from langfuse import observe

@observe() # Langfuse
def summarize_sales(q: str): ...
import mlflow

@mlflow.trace # MLflow Tracing
def summarize_sales(q: str): ...
from traceloop.sdk.decorators import workflow

@workflow(name="weekly_report") # OpenLLMetry
def summarize_sales(q: str): ...

它们省掉了属性名手打,但改动面没变 —— 仍然是"每个想看的函数都要动一次"。归类上属于 A,不属于 B。

三、做法 B:库内自动埋点

改成这样:

# 整个应用只需要这三行,位置在所有业务 import 之前
from openinference.instrumentation.openai import OpenAIInstrumentor

OpenAIInstrumentor().instrument()

# 下面的业务代码一个字都不用改,每次 client.chat.completions.create()
# 都会自动产生一个带完整属性的 chat span

代价是你要为每个用到的库装一个 instrumentor。现成的有多少:

项目许可证Python instrumentor 数侧重
Arize-ai/openinference1,159Apache-2.037Agent 框架最全:google-adk、strands-agents、smolagents、pydantic-ai、dspy、claude-agent-sdk、mcp
traceloop/openllmetry7,387Apache-2.032向量库最全:pinecone、qdrant、milvus、weaviate、chromadb、lancedb、marqo
open-telemetry/opentelemetry-python-genai31Apache-2.012OTel 官方,2026-05-12 才建仓,只覆盖主流的那几个

(数据日期 2026-08-21,gh api repos/OWNER/REPO 与仓库目录计数)

第三行值得单独说:OTel 官方正在把 GenAI 埋点收回自己的仓库。 opentelemetry-python-contrib 里原本的 instrumentation-genai/util/opentelemetry-util-genai 都标注了正在迁往 opentelemetry-python-genai。星数低不代表边缘 —— 判断这类基础设施仓库要看它是不是"上游",而不是看谁 star 了它。

3.1 内部机制:猴补丁挂在哪一层

拿 OpenInference 的 OpenAI 埋点器看,全文核心只有十几行:

# openinference/instrumentation/openai/__init__.py
from wrapt import wrap_function_wrapper

class OpenAIInstrumentor(BaseInstrumentor):
def _instrument(self, **kwargs):
openai = import_module("openai")
# 先把原函数存起来,_uninstrument 时要还回去
self._original_request = openai.OpenAI.request
self._original_async_request = openai.AsyncOpenAI.request
# wrapt 把 openai.OpenAI.request 这个属性换成一个代理对象,
# 代理内部持有原函数。调用时先开 span、记录请求属性,
# 再调原函数,拿到响应后补上 token 用量和输出属性。
wrap_function_wrapper("openai", "OpenAI.request", _Request(tracer=tracer, openai=openai))
wrap_function_wrapper("openai", "AsyncOpenAI.request", _AsyncRequest(...))

def _uninstrument(self, **kwargs):
# 还原。注意这里是直接赋值回去,不是 wrapt 的反向操作 ——
# 如果中间有第二个库也补了同一个函数,这次还原会把它一起抹掉
openai = import_module("openai")
openai.OpenAI.request = self._original_request
openai.AsyncOpenAI.request = self._original_async_request

挂点的选择比补丁技术本身重要得多。 这里补的是 OpenAI.request —— SDK 内部所有资源方法最终汇聚的那个传输层入口,而不是 client.chat.completions.create

同一个 SDK,补在不同层高,维护成本差一个数量级chat.completionsembeddingsresponses未来新增的接口← 挂这一层:四个挂点,第四个你还不知道它叫什么OpenAI.request —— SDK 内部所有资源方法的必经之路← 挂这一层:一个挂点,新接口自动覆盖httpx → 真实网络请求代价在于:传输层拿到的是序列化后的请求体和响应体,语义要自己从 JSON 里解。挂资源方法层则能直接拿到类型化的参数对象。所以这不是「哪层更好」,而是「用一处维护成本换一次解析成本」—— 前者划算得多。
再往下还有一层可以挂:httpx 本身。但那一层就分不清这次请求是发给 OpenAI 还是发给别的服务了,属性得靠 URL 猜 —— 那正是下一篇里旁路采集要面对的处境。

3.2 失效模式:版本一漂,静默无 span

同一个包里还有一行:

# openinference/instrumentation/openai/package.py
_instruments = ("openai >= 1.69.0",)

这个版本区间是硬约束。当它不满足时,你得到的不是报错,是零个 span。 OTel 的自动加载逻辑里,跳过分支长这样:

# opentelemetry/instrumentation/auto_instrumentation/_load.py
except DependencyConflictError as exc:
_logger.debug("Skipping instrumentation %s: %s", entry_point.name, exc.conflict)
continue # ← 跳过,继续下一个
except ModuleNotFoundError as exc:
_logger.debug("Skipping instrumentation %s: %s", entry_point.name, exc.msg)
continue
except ImportError:
_logger.exception("Importing of %s failed, skipping it", entry_point.name)
continue # ← K8s Operator 注入场景专门加的分支

三条跳过路径,两条记 debug 级别日志。默认日志级别下你什么都看不到,现象是"埋点装了、代码跑了、后台一条 trace 都没有"。

这是自动埋点最常见的一类线上事故,排查方式固定:

# 1. 确认实际装的库版本落在 instrumentor 声明的区间内。
# 这一步能解释绝大多数「装了但没数据」
python -c "import openai; print(openai.__version__)"
python -c "import importlib.metadata as m; print(m.requires('openinference-instrumentation-openai'))"

# 2. 确认 entry point 真的被注册了。没输出就说明包没装好或装错了环境
python -c "import importlib.metadata as m; \
print([(e.name, e.value) for e in m.entry_points(group='opentelemetry_instrumentor')])"
# 3. 看加载过程到底发生了什么。三条跳过路径记的都是 debug 级别,
# 不把这个 logger 调下来就什么都看不到
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("opentelemetry.instrumentation").setLevel(logging.DEBUG)
五个环节,每个环节坏掉的症状都不一样 —— 先看症状就能定位到环节pip 安装枚举 entry point依赖版本检查wrapt 打补丁业务调用产 span① 装错环境装进了别的 venv或别的 Pythonentry point 空② 装了两个两个包管同一个库见 7.1 节span 与账单双倍��③ 版本不匹配库版本落在区间外跳过并记 debug一条 span 都没有④ 符号被改名补的是私有方法改名不算破坏变更升个小版本就全没⑤ 补得太晚业务已持有旧引用from openai import有的有、有的没有⑤ 是里面最难查的一个:它不是「没有数据」而是「数据不全」,看板上一切正常,只有某几条链路的 span 莫名缺失。做法 C 天生免疫 ⑤ —— 它在业务代码执行之前就把补丁打完了,这是它相对做法 B 的实质收益。
五个环节里只有最后一格是绿的,因为前四格都是「还没产生数据」的准备阶段 —— 而准备阶段出的错,全部表现为「什么都没发生」。

除了版本,另外三种静默失效:

失效方式现象原因
instrument() 调用得太晚部分调用有 span,部分没有业务模块已经 from openai import OpenAI 把类引用抓在手里了,之后再补 openai.OpenAI.request 补不到已经绑定的引用
SDK 内部重构升了一个小版本后 span 全没了补丁挂的是私有方法。OpenAI.request 不在 SDK 的公开 API 契约里,改名不算破坏性变更
两个埋点库都装了每次调用产生两个 span,token 用量翻倍OpenInference 和 OpenLLMetry 补的是同一个函数,见第六节

第一条的规避方式是把 instrument() 放进最早执行的位置 —— 这恰好就是做法 C 存在的理由。

四、做法 C:进程内零代码

源码一个字不改,靠启动方式把埋点插进去。

4.1 Python:PYTHONPATH + sitecustomize

# 装好 instrumentor 之后,启动命令前面加一个词,就这样
opentelemetry-instrument python app.py

它做的事情比看上去简单。opentelemetry-instrument 本身是个只有几十行的启动器:

# opentelemetry/instrumentation/auto_instrumentation/__init__.py
filedir_path = dirname(abspath(__file__))
python_path.insert(0, filedir_path) # 把自己所在目录塞到 PYTHONPATH 最前面
environ["PYTHONPATH"] = pathsep.join(python_path)
executable = which(args.command)
execl(executable, executable, *args.command_args) # 用真正的命令替换掉当前进程

而那个目录里躺着一个文件:

# .../auto_instrumentation/sitecustomize.py —— 全文就这三行
from opentelemetry.instrumentation.auto_instrumentation import initialize

initialize()

sitecustomize 是 CPython 启动时自动 import 的特殊模块名,早于任何业务代码。于是链条闭合了:

零代码不是魔法,是四步都不需要你参与的普通 Python 机制① 改 PYTHONPATH把自己所在目录插到最前再 execl 换成真命令② 自动导入CPython 启动时必 import名为 sitecustomize 的模块③ 枚举插件按组名列出所有已安装的opentelemetry_instrumentor④ 逐个 instrument()此刻业务代码还没执行所以补丁一定补得上第三步依赖包安装时写进元数据的声明,比如 openinference-instrumentation-openai 的 pyproject.toml 里那两行:[project.entry-points.opentelemetry_instrumentor] 下面 openai = "…:OpenAIInstrumentor"。装了包就等于注册了插件。反过来说:pip 装了什么,就会埋什么。生产环境要靠 OTEL_PYTHON_DISABLED_INSTRUMENTATIONS 显式关掉不想要的。
第四步在业务代码之前执行,正好解决了 3.2 节里「instrument() 调用得太晚」那个失效模式 —— 这是 C 相对 B 的实质收益,不只是少写三行。

4.2 Java:javaagent 字节码织入

# JVM 层面拦截类加载,在方法体前后织入计时代码,不需要 PYTHONPATH 这类技巧
java -javaagent:opentelemetry-javaagent.jar -jar app.jar

但 Java 侧的 GenAI 覆盖远不如 Python。opentelemetry-java-instrumentationinstrumentation/ 目录下,与 GenAI 相关的模块只有 openai 一个。Java 技术栈上想要框架级的 Agent 追踪,目前基本只能走做法 A 或 D。

4.3 Kubernetes:Operator 注入 initContainer

平台团队最想要的形态 —— 业务团队的镜像和代码都不动,加一条 Pod 注解:

spec:
template:
metadata:
annotations:
# 取值可以是 "true"(用当前 namespace 的默认 Instrumentation 资源)、
# 具体资源名,或者 "其他namespace/资源名" 做跨 namespace 引用
instrumentation.opentelemetry.io/inject-python: "true"

opentelemetry-operator(★1,747)的 admission webhook 看到这条注解后,往 Pod 里塞一个名为 opentelemetry-auto-instrumentation 的 initContainer,把埋点库拷进共享 volume,再改业务容器的 PYTHONPATHOTEL_* 环境变量。启动后走的还是 4.1 那条链路。

业务侧的全部改动是第一格那一行注解,后面四格都发生在平台侧① 加注解在 Pod 模板上加一行inject-python: true业务改动到此为止② webhook 拦截改写 Pod spec注入 initContainer镜像没有重建③ initContainer把埋点库拷进共享 volume预构建,可能不兼容④ 业务容器启动PYTHONPATH 已被改指向共享 volume环境变量也一并注入⑤ 回到 4.1 那条链sitecustomize 执行补丁在业务代码前天然免疫「补太晚」③ 那格是实际落地时最常出问题的一环:注入的埋点库是预先构建好的,Python 版本或 libc 与业务容器对不上时会 ImportError。加载器为此专门留了一条 except ImportError 分支「跳过这一个而不是整体失败」—— 这条分支的存在本身就说明它常见。Go 另说:走 eBPF sidecar,要 privileged: true + runAsUser: 0 且不支持多容器 Pod,多数集群的 PodSecurity 策略直接卡死。
五格里业务团队只碰第一格。这也是做法 C 的全部价值所在 —— 它把「谁有权改这件事」从业务团队转移到了平台团队。

已知限制(来自官方文档,不是推测):

语言限制
Go走 eBPF sidecar,不支持多容器 Pod;需要 privileged: truerunAsUser: 0;还得设 OTEL_GO_AUTO_TARGET_EXE
DenoOTel 集成本身还不稳定,要 --unstable-otel
通用同一个容器里某些语言组合不能同时注入

Go 那行的三个限制叠起来,在多数生产集群的 PodSecurity 策略下直接过不了 —— 这是选型时要提前确认的,不是上线后再说的。

另外 3.2 节那段代码里有一条 except ImportError 分支,注释写得很直白:Operator 注入的埋点库是预先构建好的,可能和业务容器的 Python 版本、libc 对不上,导致带二进制扩展的 instrumentor 加载失败。这条分支的存在本身就说明这个场景常见到需要专门兜底。

五、做法 D:进程外旁路

进程完全不碰。两个子路线:

子路线采集点代表详见
内核探针uprobe 挂在 TLS 库的读写函数上,在加密前 / 解密后拿到明文 HTTP 载荷OBI(opentelemetry-ebpf-instrumentation03 篇
网络代理应用把 base_url 指向网关,网关转发时顺手记账LiteLLM Proxy、Helicone、Envoy AI Gateway06 篇

两者的共同天花板:它们只能看到"离开进程的东西"。 一次 LangGraph 执行内部走了哪几个节点、哪个条件边被选中、Agent 在第几轮决定放弃 —— 这些全部发生在进程内存里,从来没有变成网络包,旁路永远看不到。

反过来,它们有一个 B 和 C 都给不了的性质:不可绕过。 业务方忘记装埋点库、故意关掉埋点、或者干脆用了个没人写过 instrumentor 的冷门 SDK,旁路照样记得到。这正是计费口径必须放在这一层的原因。

六、覆盖率对照

四条路线各自能看见什么:

想看的东西A 手工B 库内C 零代码D 旁路
模型调用耗时、token 用量✅ 手写
框架内部节点(LangGraph 的每个 node)只有这条只有这条
工具调用与其参数✅ 手写部分(走 MCP over HTTP 才行)
向量库检索✅ 手写部分(OBI 支持六家向量库)
业务维度(租户、实验分组、流程名)只有这条⚠️ 网关侧可注入
未装埋点库的服务只有这条
不可伪造的计费口径只有这条

四项能力各有唯一的来源,且分散在谱系的两端和中段。

四项独占能力落在谱系的三个不同位置 —— 所以「选一个」注定漏掉其中两项侵入性高侵入性低A 手工B 库内 / C 零代码D 旁路业务维度租户、实验分组、流程名框架内部节点LangGraph 走了哪几个 node未装埋点库的服务不可伪造的计费口径 · 业务方绕不过去中段那格常被误以为「可有可无」,其实它是唯一能回答「模型在第几步决定放弃」的路线 —— 而那正是 Agent 排查的主要对象。谱系两端各自看不见对方:A 看不见框架内部,D 看不见进程内部。这不是实现不足,是取数位置决定的。
横轴与本篇第一张图是同一条谱系。把两张图叠起来看:改动面从左到右递减,而独占能力并不跟着递减 —— 它们是散落的。

这就是为什么生产系统必然是混用,而不是选一个。

七、混用时的两个坑

7.1 同一个函数被补两次,而且关不掉一个

openinference-instrumentation-openaiopentelemetry-instrumentation-openai-v2 补的是同一个 openai 包。两个都装上,一次调用产生两个 span:

# ❌ 两个埋点库同时生效
# 后果不只是 span 数量翻倍 —— 成本报表按 gen_ai.usage.input_tokens
# 求和时,同一次调用的 token 会被算两遍,账单直接翻倍
OpenAIInstrumentor().instrument() # OpenInference
OpenAIInstrumentorV2().instrument() # OTel 官方

做法 B 下这个问题好办 —— 少调一行就行。做法 C 下它几乎无解,原因藏在两个包的 pyproject.toml 里:

# openinference-instrumentation-openai/pyproject.toml
[project.entry-points.opentelemetry_instrumentor]
openai = "openinference.instrumentation.openai:OpenAIInstrumentor"

# opentelemetry-instrumentation-openai-v2/pyproject.toml
[project.entry-points.opentelemetry_instrumentor]
openai = "opentelemetry.instrumentation.openai_v2:OpenAIInstrumentor"

两个不同的包,注册在同一个组下、用了同一个名字 openai 而关闭开关是按这个名字匹配的:

# opentelemetry/instrumentation/auto_instrumentation/_load.py
package_to_exclude = environ.get(OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, [])
# ...
for entry_point in entry_points(group="opentelemetry_instrumentor"):
if SKIPPED_INSTRUMENTATIONS_WILDCARD in package_to_exclude: # 值是 "*"
break
if entry_point.name in package_to_exclude: # ← 按名字匹配
_logger.debug("Instrumentation skipped for library %s", entry_point.name)
continue
名字撞了,于是「开」和「关」都只能是两个一起openinference-instrumentation-openai声明在 opentelemetry_instrumentor 组下名字:openaiopentelemetry-instrumentation-openai-v2声明在同一个组下名字:openai ← 撞了entry_points(group=…)返回两条,name 都是 openai不设 DISABLED 开关两个 instrumentor 都跑同一次调用两个 span,token 算两遍设 DISABLED="openai"按 name 匹配 → 两条都命中一起被关掉,还是零个 span加载器那行 if entry_point.name in package_to_exclude 按名字匹配,而名字由包作者各自决定 —— 撞名不是 bug,是没人协调。零代码模式下只有两条出路:卸载掉不要的那个包,或者退回做法 B 手动 instrument。
右边两格都是坏的,中间没有「只留一个」的挡位 —— 这正是这个坑难受的地方:它不是配错了,是这一层压根没提供那个旋钮。

于是:

# 这一行会把两个都关掉,而不是只关掉其中一个
export OTEL_PYTHON_DISABLED_INSTRUMENTATIONS="openai"

零代码模式下没有办法只保留其中一个 —— 只能卸载掉不要的那个包,或者退回做法 B 手动调用。

这个坑在做法 C 下格外容易踩:entry point 是"装了就生效",没人会在启动日志里看到"你装了两个管 openai 的埋点库"。排查方式是把 opentelemetry.instrumentation 这个 logger 调到 DEBUG,看它到底加载了几个:

# 放在应用启动最前面,或者用 opentelemetry-instrument 时写进 sitecustomize 之外的
# 任意早期位置。三条跳过路径记的都是 debug,不开这个什么都看不到
import logging
logging.getLogger("opentelemetry.instrumentation").setLevel(logging.DEBUG)
logging.basicConfig(level=logging.DEBUG)

7.2 B/C 与 D 的 span 对不上号

同一次模型调用,进程内的 instrumentor 产生一个 chat span,网关侧又产生一个自己的 span。想让它们在同一棵树里,前提是 W3C traceparent 头能一路传下去

# 进程内埋点会自动注入 traceparent 到出站 HTTP 头,但前提是
# HTTP 客户端也被埋点了。只装 openai 的埋点器、没装 httpx 的,
# 出站请求就不带 traceparent —— 网关侧那个 span 于是成了孤儿根节点。
# 症状:后台里同一次调用出现两棵独立的树,怎么找都关联不上。

网关侧的 span 如果成了孤儿,成本归因就只剩网关自己那份数据可用,进程内那些"这次属于哪个业务流程"的属性关联不上。06 篇会说这一层具体怎么打通。

八、怎么选

情况走哪条
用了 LangGraph / LlamaIndex / CrewAI 这类编排框架B 或 C 必选。 框架内部结构是排查的主要对象,手工埋点看不到
代码归业务团队、观测归平台团队C。 平台团队没有改业务代码的权限,Pod 注解是唯一的抓手
要算钱、要防绕过D 的网关子路线必选,且计费只认这一份数据
多语言混合,Java / Go 占比高D 为主。 Java 侧 GenAI 埋点只有 openai 一个模块,Go 侧靠 eBPF
需要"这次执行属于哪个业务流程"补 A。 只在根 span 上加几个属性即可,不必全面手工埋
只是想在本地看看 trace 长什么样B。 三行代码,装个 Phoenix 直接看

一句话版本:B/C 拿结构,D 拿账,A 补业务维度。 三者不冲突,冲突的是"以为选一个就够了"。

下一篇03 - eBPF 旁路采集

← 回到 专题索引  ·  Agent Infra 板块总览